iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 26

Kotlin 手刻 Ktor 從零開始 Day 26 Dependency Injection,極簡的服務容器

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

應用開始變大之後,handler 裡常常需要各種 service 和 repository,如果每次都手動 new,會很難測試 (沒辦法替換成 mock),也很難管理生命週期 (singleton、connection pool),而且依賴關係會散落的到處都是

這篇會在 Relix 內建一個極簡 DI 容器,不是 Spring 那種全自動掃描注入,而是手動宣告、型別安全、夠用就好

目標 API

val app = Relix { }
app.install(ContentNegotiation) { json() }   // ok(List<String>) 要走 JSON 序列化
app.services {
    singleton<UserRepository> { InMemoryUserRepository() }
    singleton<UserService> { UserService(resolve()) }
}

app.routing {
    get("/users") {
        val service = resolve<UserService>()
        ok(service.list())
    }
}

singleton<UserRepository> { ... } 宣告一個 binding,resolve() 在 provider 內部可以拿其他依賴,handler 裡的 resolve<UserService>() 透過 RelixCall 委派到 application 的 container

TDD 先用測試確認容器行為

容器本身不碰 HTTP,建好直接呼叫 singleton()resolve() 就測得到,只有最後一個「handler 拿得到服務」要走 TestKit,四種行為都在同一組,測試檔案放 ServiceContainerTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertFailsWith

interface UserRepository {
    fun findAll(): List<String>
}

class InMemoryUserRepository : UserRepository {
    override fun findAll() = listOf("Alice", "Bob")
}

class UserService(private val repo: UserRepository) {
    fun list() = repo.findAll()
}

class ServiceContainerTest {

    @Test
    fun `singleton returns same instance`() {
        val container = ServiceContainer()
        container.singleton<UserRepository> { InMemoryUserRepository() }

        val a = container.resolve<UserRepository>()
        val b = container.resolve<UserRepository>()

        assertSame(a, b)
    }

    @Test
    fun `factory returns different instances`() {
        val container = ServiceContainer()
        container.factory<UserRepository> { InMemoryUserRepository() }

        val a = container.resolve<UserRepository>()
        val b = container.resolve<UserRepository>()

        assertNotSame(a, b)
    }

    @Test
    fun `resolve chains dependencies`() {
        val container = ServiceContainer()
        container.singleton<UserRepository> { InMemoryUserRepository() }
        container.singleton<UserService> { UserService(resolve()) }

        val service = container.resolve<UserService>()

        assertEquals(listOf("Alice", "Bob"), service.list())
    }

    @Test
    fun `resolve missing binding throws`() {
        val container = ServiceContainer()

        assertFailsWith<IllegalStateException> {
            container.resolve<UserService>()
        }
    }

    @Test
    fun `handler can resolve services`() {
        val app = RelixApplication()
        app.services {
            singleton<UserRepository> { InMemoryUserRepository() }
            singleton<UserService> { UserService(resolve()) }
        }
        app.routing {
            get("/users") {
                val service = resolve<UserService>()
                ok(service.list().joinToString(","))
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/users")

        assertEquals(200, response.statusCode)
        assertEquals("Alice,Bob", response.body.toString(Charsets.UTF_8))
    }
}

ServiceContainer

容器跟框架既有的檔案沒有相依關係,開一個 ServiceContainer.kt 放它

import kotlin.reflect.KClass

class ServiceContainer {
    @PublishedApi
    internal val singletons = mutableMapOf<KClass<*>, Lazy<Any>>()
    @PublishedApi
    internal val factories = mutableMapOf<KClass<*>, () -> Any>()

    inline fun <reified T : Any> singleton(noinline provider: ServiceContainer.() -> T) {
        singletons[T::class] = lazy { provider() }
    }

    inline fun <reified T : Any> factory(noinline provider: ServiceContainer.() -> T) {
        factories[T::class] = { provider() }
    }

    inline fun <reified T : Any> resolve(): T {
        return resolveByClass(T::class) as T
    }

    fun resolveByClass(klass: KClass<*>): Any {
        singletons[klass]?.let { return it.value }
        factories[klass]?.let { return it() }
        throw IllegalStateException(
            "No binding found for ${klass.simpleName}. " +
            "Did you forget to register it with singleton<${klass.simpleName}> { ... }?"
        )
    }
}

singletonsLazy<Any> 包,第一次 resolve 時才執行 provider,之後回同一個 instance,factories 每次呼叫都執行 provider

key 是 KClass<*>,這裡用 KClass 基本上夠用了,但有個限制,singleton<List<String>>singleton<List<Int>>KClass 都是 List::class,會衝突,真要支援泛型需要用 KType

resolve() 先找 singleton,再找 factory,都找不到丟 IllegalStateException,錯誤訊息告訴你是哪個型別沒註冊

provider 裡的 resolve()

singleton<UserService> { UserService(resolve()) }

為什麼 provider 裡面可以呼叫 resolve() ? 因為 provider 的簽名是 ServiceContainer.() -> T,receiver 是 ServiceContainer,在 lambda 裡面 this 就是 container,所以可以直接打 resolve<UserRepository>()

這讓依賴組裝變得自然,UserService 需要 UserRepository,provider 直接 resolve() 拿到,container 會根據 singleton 的 Lazy 確保 UserRepository 只建立一次

services DSL 入口

容器要掛在 application 上才有人拿得到,調整 RelixApplication.kt,補一個欄位跟一個方法

val container = ServiceContainer()

fun services(block: ServiceContainer.() -> Unit) {
    container.block()
}

services { }ServiceContainer 當 receiver 傳進去,在裡面可以呼叫 singleton<T>()factory<T>()

handler 裡的 resolve

這段要補到 ServiceContainer.kt,寫在 class 外面,它是 top-level function

inline fun <reified T : Any> RelixCall.resolve(): T {
    return application.container.resolve<T>()
}

handler 的 resolve<UserService>() 委派到 application.container.resolve<UserService>(),handler 不需要知道 container 的存在,直接用就好

這裡用 extension function 而不是 member,是刻意的,DI 不是 RelixCall 的核心責任,寫成 extension 就能跟 ServiceContainer 放在同一個檔案,RelixCall 不必為了它多一個依賴

第 22 篇的 unprocessableEntity()、第 24 篇的 serveStaticFile() 也是同一個理由,反過來說,第 20 篇的 ok() 和第 21 篇的 receive() 寫成 member,是因為它們要跟字串版本做多載解析,放在同一個類別裡規則最單純

get("/users") {
    val service = resolve<UserService>()
    ok(service.list())
}

把 logger 註冊進容器

第 15 篇的 loggingMiddleware(logger) 是把 logger 從外面傳進 middleware,middleware 有得用,handler 裡想印一行 log 就沒辦法,只能自己 new 一個,或是靠全域變數傳

有了容器就不用這樣繞,logger 跟其他服務一樣註冊進去就好

val app = Relix {
    logLevel = LogLevel.INFO
}

val logger = ConsoleLogger(app.config.logLevel)

app.use(loggingMiddleware(logger))
app.services {
    singleton<RelixLogger> { logger }
}

這裡刻意在 services { } 外面先建好 logger 再註冊進去,而不是寫成 singleton<RelixLogger> { ConsoleLogger(app.config.logLevel) },因為 middleware 跟 handler 要拿到同一個實例,而 app.use() 那一刻就要有 logger,binding 卻還沒註冊,resolve 不到就只好自己再 new 一個,兩邊變成兩個不同的物件,硬要走 provider 那條路也不是不行,就得先 app.services { }app.use(loggingMiddleware(app.container.resolve())),多綁一層順序,註冊一個已經存在的實例是容器最單純的用法,provider 直接回傳它就好

handler 裡的用法跟其他服務沒有兩樣

post("/orders") {
    val logger = resolve<RelixLogger>()
    val orderService = resolve<OrderService>()
    val order = receive<CreateOrderRequest>()

    logger.info("Creating order for user ${order.userId}")
    ok(orderService.create(order))
}

binding 的型別寫 RelixLogger 而不是 ConsoleLogger,是為了讓 handler 只依賴介面。測試時把容器裡換成 FakeLogger,handler 完全不用改

app.services {
    singleton<RelixLogger> { FakeLogger() }
}

測試裡 resolve<RelixLogger>() 拿到的就是 FakeLogger,可以直接檢查 messages 的內容,middleware 那邊要一起換,loggingMiddleware(fakeLogger) 傳同一個進去就好

這正是第 15 篇把 logger 抽成介面的回報,那時候只有 middleware 受惠,現在整個應用層都吃得到

在 main 裡組起來跑一次

測試驗的是「註冊了就 resolve 得到」,真的跑起來才看得出 singleton 跟 factory 差在哪,還有依賴少註冊一個的時候會死在什麼地方

fun main() {
    val app = Relix {
        port = 8080
        logLevel = LogLevel.INFO
    }

    app.install(ContentNegotiation) { json() }

    val logger = ConsoleLogger(app.config.logLevel)
    app.use(loggingMiddleware(logger))
    app.use(errorHandlingMiddleware(development = true, logger = logger))

    app.services {
        singleton<RelixLogger> { logger }
        singleton<UserRepository> { InMemoryUserRepository() }
        singleton<UserService> { UserService(resolve()) }
        factory<RequestId> { RequestId(java.util.UUID.randomUUID().toString().take(8)) }
    }

    app.routing {
        get("/users") {
            val service = resolve<UserService>()
            ok(service.list())
        }

        get("/identity") {
            val service = resolve<UserService>()
            val requestId = resolve<RequestId>()
            ok("service=${System.identityHashCode(service)} requestId=${requestId.value}")
        }

        get("/log") {
            val log = resolve<RelixLogger>()
            log.info("handler logger = ${System.identityHashCode(log)}")
            ok("logger=${System.identityHashCode(log)} middlewareLogger=${System.identityHashCode(logger)}")
        }

        get("/orders") {
            val orderService = resolve<OrderService>()
            ok("never reached: $orderService")
        }
    }

    app.start()
}

這個 main 用到五個型別,來源不太一樣,先交代清楚,不然編譯不過

UserRepositoryInMemoryUserRepositoryUserService 前面是宣告在 ServiceContainerTest.kt 裡的,那是為了讓測試自己帶著 fixture,真的要跑起來的時候它們屬於正式程式碼,搬到 main.kt 去,測試那邊的宣告跟著刪掉,理由跟第 21 篇的 CreateUserRequest、第 22 篇的 RegisterRequest 一樣,同 package 兩邊各留一份雖然編譯得過 (test 的宣告會遮蔽 main 的),但兩份定義不同步的時候很難查

interface UserRepository {
    fun findAll(): List<String>
}

class InMemoryUserRepository : UserRepository {
    override fun findAll() = listOf("Alice", "Bob")
}

class UserService(private val repo: UserRepository) {
    fun list() = repo.findAll()
}

另外兩個是為了這一節才加的,RequestId 拿來看 factory 每次是不是真的建新的,OrderService 故意不註冊,等一下要看它怎麼死,兩個都只是空殼,跟上面三個放在一起

class RequestId(val value: String)

class OrderService

依賴鏈真的接起來了

curl -i localhost:8080/users
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 15

["Alice","Bob"]

UserService 的 provider 裡那個 resolve() 沒有指定型別,是靠建構子參數的位置推出來要 UserRepository 的,這條線在測試裡看起來就是 assertEquals,實際跑一次才知道它從 HTTP 那一端也走得通

singleton 每次都是同一個,factory 每次都是新的

curl localhost:8080/identity
curl localhost:8080/identity
service=1579219795 requestId=49bfaf9a
service=1579219795 requestId=accf93e5

兩次 request 拿到的 UserService 是同一個物件,Lazy 只算了一次,RequestId 則是每次 resolve 都跑一次 provider,所以兩個值不一樣,這正是 singletonfactory 的分界,前面 assertSameassertNotSame 講的就是這件事

middleware 跟 handler 拿到同一個 logger

curl localhost:8080/log
logger=1736408392 middlewareLogger=1736408392
[Relix] handler logger = 1736408392
[Relix] GET /log -> 200 (3ms)

兩個數字一樣,前面說「刻意先建好 logger 再註冊進去」就是為了這個結果,如果 binding 寫成 singleton<RelixLogger> { ConsoleLogger(app.config.logLevel) },這兩個數字會不一樣,log 看起來還是正常的,但你手上其實有兩個 logger

少註冊一個依賴

curl -i localhost:8080/orders
HTTP/1.1 500 Internal Server Error
Content-type: text/plain; charset=utf-8
Content-length: 43

Internal Server Error
IllegalStateException

client 拿到的就這兩行,第二行的 IllegalStateException 是因為 main 裡開了 development = true,第 16 篇說過,這個模式只多回一個 class 名稱,原始 message 跟 stack trace 都不會出去,所以 client 這邊看不出是哪個型別沒註冊,真正的線索在 server

[Relix ERROR] Unhandled exception
java.lang.IllegalStateException: No binding found for OrderService. Did you forget to register it with singleton<OrderService> { ... }?
    at ServiceContainer.resolveByClass(ServiceContainer.kt:32)
    at MainKt$main$3$4.invokeSuspend(main.kt:68)
    at ErrorHandlingMiddlewareKt$errorHandlingMiddleware$1.invokeSuspend(ErrorHandlingMiddleware.kt:12)
    at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
    at LoggingMiddlewareKt$loggingMiddleware$1.invokeSuspend(LoggingMiddleware.kt:14)
    at RelixApplication.handle(RelixApplication.kt:105)
    ...

每個 suspend lambda 都會多出兩個 invoke 的框架,kotlinx.coroutines 那一大段也一併省掉了,留下來的這幾行才是重點

No binding found 那句在這裡才顯得有價值,它把型別名稱跟該補的那行程式碼一起講完了,下一個框架直接指到 main.kt 是哪一行在 resolve,再往下就是第 16 篇那張 middleware 巢狀圖的實體,logging 在外、error handling 在內,中間夾著 buildPipeline

同時也看到這個容器最大的弱點,OrderService 沒註冊這件事,是有人打了 /orders 才發現的,server 啟動的時候一切正常,Relix started on 0.0.0.0:8080 照樣印,沒有人事先檢查依賴圖完不完整,這條路由可能上線三天都沒人走到

常見陷阱與設計取捨

為什麼不自動掃描 class path ?

Spring 用 annotation + class path scanning 自動發現 bean,這需要 reflection 掃描整個 class path,啟動慢、debug 難 (你不知道某個 bean 是從哪裡被掃描進來的),手動宣告 singleton<T> { } 比較囉唆,但依賴關係一目了然,這裡的選擇明確勝過魔法

singleton 的 lazy 初始化順序會不會有問題 ?

Lazy 預設使用 LazyThreadSafetyMode.SYNCHRONIZED,第一次 resolve 時觸發初始化,若 A 與 B 互相依賴,同一個 thread 通常會不斷遞迴直到 StackOverflowError,跨 thread 的初始化才可能互相等待,這裡不偵測循環依賴,正式容器應維護解析中的型別 stack,發現重複時立刻回報清楚錯誤

要加偵測其實不難,骨架大概是這樣

private val resolving = ThreadLocal.withInitial { mutableSetOf<KClass<*>>() }

fun <T : Any> resolve(klass: KClass<T>): T {
    val visiting = resolving.get()
    if (klass in visiting) {
        throw IllegalStateException("Cycle detected: ${visiting.joinToString(" -> ")} -> ${klass.simpleName}")
    }
    visiting += klass
    try {
        return providers[klass]!!.invoke() as T
    } finally {
        visiting -= klass
    }
}

這個做法以 thread-local 集合記錄正在解析的型別,重複出現時立刻拋例外,完整容器還應把依賴鏈放進錯誤訊息,編譯期 DI 則能更早檢查整張依賴圖

為什麼不支援 scope (per-request binding) ?

per-request scope 需要在每個 request 開始時建立一個子 container,結束時銷毀,對於這裡來說太複雜,如果你需要 per-request 的東西,放在 CallContext 裡就好 (第 12 篇的機制)

這個容器不建議真的拿去用

上面那個「上線三天才發現少一個 binding」不是實作沒寫好,是這種設計本來就會這樣,ServiceContainer 全部的邏輯不到三十行,看得懂 DI 在做什麼綽綽有餘,拿去接真的專案就差得遠了

差在哪,前面已經散著講過幾個,這裡一次列清楚

  • 沒有編譯期檢查,Dagger 跟 Hilt 是在編譯的時候把整張依賴圖攤開來檢查的,少一個 binding 直接編譯不過,我們這個要跑到那一行才知道
  • 沒有啟動時的驗證,Spring 預設會在啟動階段就把 singleton 全部建好,Ktor 自己那套 DI 也會在 server 起來之前把註冊過的依賴全 resolve 一遍,設定錯了根本起不來,我們的 Lazy 是等到第一次 resolve 才動
  • key 只有 KClassList<String>List<Int> 會撞在一起,也沒有 qualifier 可以區分同一個介面的兩個實作 (主資料庫跟唯讀複本這種)
  • 沒有 scope,只有 singleton 跟 factory 兩種,per-request、per-session 都沒有
  • 沒有生命週期收尾,connection pool、thread pool 這些東西關閉的時候要有人呼叫 close(),容器完全不管
  • 不偵測循環依賴,前面那段 ThreadLocal 只是骨架,沒有真的裝進去
  • 兩個 map 都是普通的 mutableMap,啟動階段一次註冊完、之後只讀還好,邊跑邊註冊就不安全了

Kotlin 這邊要挑現成的,Koin 是純 Kotlin 的 runtime DI,DSL 跟我們這個很像但完整得多,Kodein 也是同一類,要編譯期保證就往 Dagger 或 Hilt 走,Ktor 從 3.2.0 開始自己內建了 DI,DSL 是 dependencies { provide<T> { } },取用寫 dependencies.resolve<T>() 或用 property delegation 的 by dependencies,key 是一個 DependencyKey,裡面除了完整的型別資訊還有一個 name,所以泛型不會撞、同型別也分得出兩個實作,它還做了 covariant 解析 (註冊 ArrayList<String>,要 List<String> 也拿得到)、suspend 的依賴初始化,以及 AutoCloseable 的自動關閉,它還會在 ApplicationModulesLoaded 的時候把整張圖驗過一遍,少一個就丟 DependencyInjectionException,前面那個「上線三天才發現」的情境在它身上不會發生,這七條它幾乎都補上了

話說回來,這不到三十行的程式碼本身不是重點,它的用處是讓 DI 沒有神祕感,容器就是一個 map,key 是型別,value 是「怎麼生出這個東西」的一段程式,resolve 就是查表加上呼叫,Spring 的 @Autowired 跟 Koin 的 by inject() 底下做的是同一件事,剩下的差別就是上面列的那些


小結

ServiceContainerKClass 當 key,Lazy<Any> 實作 singleton,普通 lambda 實作 factory,provider 的 receiver 是 container 本身,所以可以在裡面 resolve() 其他依賴,handler 透過 RelixCall.resolve<T>() 委派到 application 的 container,手動宣告比自動掃描囉唆,但依賴關係清楚可追蹤,這個容器是拿來看懂 DI 的原理的,正式專案還是交給 Koin、Dagger 或 Ktor 自己內建的那套


下一篇

下一篇做 Status Pages,把第 16 篇的最小 error handling 升級成可客製的錯誤映射,統一輸出格式,避免洩漏敏感資訊


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 25 Configuration,框架的設定系統
系列文
Kotlin 手刻 Ktor 從零開始26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言